翻訳: Parse, don’t validate (バリデーションせずパースせよ)
やっと読んだ。
というかいつものようにNotebookLM読んでもらった。
ごく端的に言えば パーサーとは、より構造化されていない入力を消費して、より構造化された出力を生み出す関数 ということと理解できた。
アンチパターンとしてのショットガンパーシング
入力のパースやバリデーションコードが処理コードと混ざり合って分散してしまった状態
プログラムの状態予測が困難になったり、巻き戻しが必要になったりするリスクがあるそう
要するに「トランザクショナルスクリプト」な状態か
回避アプローチ: パースフェーズと実行フェーズを分ける
可能な限り厳格なデータ構造として値オブジェクトをモデリング し、特定の可能性を排除できるようにする
可能な限り早い段階(システムの境界)で、データを最も厳密な表現にパースする
つまり DDDにおける「モデル変換装置」に当たりそう
単にvalidateするだけでなく、適合した型に変換してドメイン層に渡せと
非正規化されたデータ型を使うのを避けること←特にミュータブルなときは
Single Source of Truthを求める
どうしても必要な場合は、カプセル化して小さなモジュールに責任を持たせる
つまり、値はimmutableに扱い、信頼できる唯一の情報源としろということ
mutableにする必要があるなら、カプセル化(抽象化)してそのデータと操作を不可分にしろと
というか「非正規化されたデータ型」とか「Single Source of Truth」って説明が話をややこしくしてる気がする
バリデーターを抽象データ型を使ってパーサーに「見える」ようにする
スマートコンストラクタ を使うことで不正な状態を表現できない型を作り出す
バリデーターをパーサーとして「偽装」する
つまり、これまた微妙に意味が分からんが、validationしているロジックで、然るべき抽象データ型へのparse(変換)を行うことで、validatorをparserに偽装できるってことなのか?
リファクタリングへのアドバイスと考えればしっくりくるかも
解説原文
まずは、ソース「翻訳: Parse, don’t validate (バリデーションせずパースせよ)」を読み進めていきましょう。
このソースの核心は、タイトルにもなっている 「Parse, don’t validate」(バリデーションせずパースせよ) というスローガンにあります。これは、特に型駆動設計のエッセンスとして紹介されていますね。
具体的にどういうことか、Haskellの例を用いて説明されています。例えば、リストの最初の要素を取り出すhead関数を考えてみましょう。空かもしれないリスト [a] を受け取って a を返す型定義 head :: [a] -> a では、空のリストが入力された場合に対応できません。これは「部分関数」と呼ばれるものだそうです。
この部分関数を「全域関数」(すべての可能な入力に対して定義されている関数)にする方法が二つ提示されています。
一つは、戻り値の型を弱める方法。つまり、「要素があるかもしれないし、ないかもしれない」という可能性を表現するために、Maybe a 型を返すようにする (head :: [a] -> Maybe a)。この場合、空のリストの場合は Nothing を返すことで、関数としては全域関数になります。
しかし、この方法には「隠れたコストがある」と指摘されています。呼び出し側は常に Maybe a が返ってくる可能性に対処しなければならず、たとえコードを書いた人が「ここでは絶対に空にならない」と分かっていても、冗長なチェックが必要になってしまう。そして、もし将来的に前提が崩れた場合、チェック漏れによるバグにつながる可能性があるのです。静的に「あり得ない」ことを証明できない、型システムに穴が開いてしまう状態と言えるのかもしれません。
もう一つは、引数の型を強める方法です。つまり、関数に渡されるリストが「空ではない」ことを型レベルで保証するのです。Haskellには NonEmpty a という、まさに空ではないリストを表す型があるそうで、これを使うことで、head関数の型を head :: NonEmpty a -> a と定義できます。この関数は、入力が空ではないことが型で保証されているため、常に要素を返すことができ、もはや部分関数ではなくなります。
このアプローチの利点は、型システムの中に「リストが空ではない」という知識を保持できることです。空のリストかどうかをチェックする処理は、[a] から NonEmpty a を作り出す nonEmpty 関数の内部に閉じ込められ、一度 NonEmpty a 型の値を得れば、その後は空ではないことを気にせず head 関数を使えるようになります。もし将来、NonEmpty a を作り出す部分のチェックが不十分になれば、型検証が失敗し、コンパイル時に問題を検知できる可能性が高まるのです。
ここで「バリデーションせずパースせよ」というスローガンに戻ります。ソースでは、validateNonEmpty :: [a] -> IO () のような関数(チェックして、情報を持たない () を返す)と、parseNonEmpty :: [a] -> IO (NonEmpty a) のような関数(チェックして、より構造化された NonEmpty a を返す)を比較しています。どちらも同じチェックをしていますが、parseNonEmpty はチェックの結果得られた「知識」(リストが空ではないという事実)を型として保持し、呼び出し側に提供しているのに対し、validateNonEmpty はその情報を捨ててしまっている。
「パーサーとは、より構造化されていない入力を消費して、より構造化された出力を生み出す関数」だと定義し、入力値のチェックをシステムの境界で行い、一度パースして厳密な型に変換すれば、それ以降は再チェックが不要になる、その力が「パースの力」だとしています。
一方、「バリデーションの危険性」として、ショットガンパーシングというアンチパターンが挙げられています。これは、入力のパースやバリデーションコードが処理コードと混ざり合って分散し、体系的な正当性なしにチェックを投げかけ、後になって不正な入力だったと気づき、プログラムの状態予測が困難になったり、巻き戻しが必要になったりするリスクがある。パースは、パースフェーズと実行フェーズを分けることで、この問題を回避するアプローチだとしています。
では、これを実践するにはどうすれば良いのか? ソースではいくつかのシンプルなアイデアを挙げています。
1. 不正な状態を表現できないようなデータ構造を使うこと。可能な限り厳格なデータ構造でモデリングし、特定の可能性を排除できるようにする。
2. 証明する責任を可能な限り押し上げること。つまり、可能な限り早い段階、システムの境界で、データを最も厳密な表現にパースする。
3. 複数パスでデータをパースすることを恐れないこと。
4. 非正規化されたデータ型を使うのを避けること。Single Source of Truthを求める。どうしても必要な場合は、カプセル化して小さなモジュールに責任を持たせる。
5. バリデーターを抽象データ型を使ってパーサーに「見える」ようにする、つまり スマートコンストラクタ を使うことで、不正な状態を表現できない型を作り出す(バリデーターをパーサーとして「偽装」する)。
これらのアイデアは、Haskellのような静的型付け言語を前提としていますが、その根底にある考え方は、静的型付け言語でなくても非常に重要だと感じました。これは、私たちが普段向き合っている本質的な複雑性やドメインモデリング、そして**DDD(ドメイン駆動設計)**の考え方と深く繋がっているのではないでしょうか?
例えば、不正な状態を表現できないデータ構造を使うという考え方は、DDDにおける値オブジェクト (Value Object)や、エンティティが常に一貫した状態を保つべきだという考え方に他ならないように思います。ソースでは、制約や仕様といった概念をモデル化することに触れていますが、これも「オブジェクトが持つ値」や「オブジェクトがビジネスルールに準拠しているか」を保証する仕組みであり、不正な状態を防ぐためのものと言えるでしょう。ソースでは、booleanフラグでなくActivationStatusのような値オブジェクトで状態を表現する方が、将来の変更に強く、不正な状態(あり得ない状態遷移)を防ぎやすいことに触れていました。これはまさに「不正な状態を表現できないデータ構造」を選ぶことの一例ではないでしょうか。
また、「システムの境界で可能な限り早期にデータを最も厳密な表現にパースする」という考え方は、アーキテクチャ設計における重要な原則と結びつきます。例えば、クリーンアーキテクチャや**ヘキサゴナルアーキテクチャ(ポートとアダプター)**では、外部システムやUIといった具体的な技術(実装の詳細)からビジネスルール(ドメイン)を分離することを重視します。外部からの入力データは、そのままドメイン層に渡されるのではなく、境界(Interface Adapters層など)でドメイン層が理解できる形(厳密な型やドメインオブジェクト)に変換されるべきだ、と考えられます。ソースで述べられている「入出力と計算・判断の分離」、「業務の関心と実装の詳細の分離」という考え方とも完全に一致するように感じます。外部からの「構造化されていない入力」を、ドメイン層が扱う「より構造化された(かつ不正な状態を表現できない)出力」に変換する、これこそが境界で行われるべき「パース」処理なのでしょう。
この「Parse, don’t validate」という考え方を徹底することは、結果的に技術的負債の蓄積を防ぎ、システムの保守性を高めることにも繋がります。ショットガンパーシングのようにチェックが散らばっていると、どこでどんなチェックが行われているか分からなくなり、コードの理解や修正が難しくなります。しかし、システムの境界で一度パースして厳密な型にしてしまえば、以降のコードは「このデータは正しい状態である」という前提で書けるため、シンプルになり、予測可能性も高まります。
AI時代におけるエンジニアの役割を考える上でも、この思想は示唆に富んでいます。AIが偶有的複雑性(実装の細部など)をある程度引き受けてくれるようになっても、人間は本質的な複雑性、つまり「複雑な現実の中から本質を見抜き、価値あるモデルを構築する」部分にフォーカスする必要があると言われています。適切なデータ構造を選び、境界でパースしてドメインモデルに変換するといった「ちゃんと設計する」部分は、まさにAIに任せられるほど十分にタスクを細分化し、「目的に沿って適切に関心が分離され」たモジュールを作るために不可欠な思考プロセスなのではないでしょうか。不正な状態を表現できない厳密な型やデータ構造は、AIがコードを理解し、正確に生成するための強力な手がかりとなる可能性も考えられます。
ただし、ソースでも述べられているように、この原則は「目指すべき理想」であり、「絶対に守るべき厳格な要件ではない」。現実には、パフォーマンスや実装コスト、あるいはレガシーシステムとの連携など、様々な制約の中でトレードオフが発生します。常に理想を追求するのが難しい場面もあるでしょう。動的型付け言語においては、静的な型システムのような厳密な保証は得られませんが、それでも値オブジェクトの導入や、入力データを変換するファクトリ/サービスのような役割を設けることで、この考え方を部分的に適用し、チェックを一元化したり、情報の意味を明確にしたりすることは十分に可能だと思います。
この「Parse, don’t validate」という考え方、皆さんの経験や他のソースの内容と照らし合わせて、どう思われますか? システム設計における必然性や価値提供といった観点から、さらに掘り下げられる点はあるでしょうか?